在 Day 21 我們建立了 Vitest 和 React Testing Library 的測試環境。今天我們要實作元件測試的進階技巧和測試驅動開發 (TDD) 的實踐,透過實際案例學習如何開發高品質的前端測試。
/**
 * TDD 的紅綠重構循環
 *
 * 🔴 Red: 先寫一個會失敗的測試
 *    - 定義預期行為
 *    - 確保測試真的會失敗
 *
 * 🟢 Green: 寫最少的程式碼讓測試通過
 *    - 只要能通過測試即可
 *    - 先求有,再求好
 *
 * 🔵 Refactor: 重構程式碼但保持測試通過
 *    - 改善程式碼品質
 *    - 消除重複
 *    - 優化設計
 *
 * 重複這個循環,直到功能完成
 */
讓我們用 TDD 的方式從零開始開發一個租戶切換元件:
// src/components/TenantSwitcher/TenantSwitcher.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { TenantSwitcher } from './TenantSwitcher';
describe('TenantSwitcher', () => {
  // 🔴 紅: 先寫測試
  it('should display current tenant name', () => {
    const currentTenant = {
      id: '1',
      name: 'Gym A',
      slug: 'gym-a',
    };
    render(<TenantSwitcher current={currentTenant} tenants={[]} />);
    // 期望看到當前租戶名稱
    expect(screen.getByText('Gym A')).toBeInTheDocument();
  });
});
執行測試,應該會失敗 (因為元件還不存在)。現在寫最簡單的實作:
// src/components/TenantSwitcher/TenantSwitcher.tsx
// 🟢 綠: 最簡單的實作
interface Tenant {
  id: string;
  name: string;
  slug: string;
}
interface TenantSwitcherProps {
  current: Tenant;
  tenants: Tenant[];
}
export function TenantSwitcher({ current }: TenantSwitcherProps) {
  return <div>{current.name}</div>;
}
測試通過! 但程式碼還很陽春,繼續下一個測試:
// TenantSwitcher.test.tsx
it('should show tenant list when clicked', async () => {
  const user = userEvent.setup();
  const currentTenant = {
    id: '1',
    name: 'Gym A',
    slug: 'gym-a',
  };
  const tenants = [
    currentTenant,
    { id: '2', name: 'Gym B', slug: 'gym-b' },
    { id: '3', name: 'Gym C', slug: 'gym-c' },
  ];
  render(<TenantSwitcher current={currentTenant} tenants={tenants} />);
  // 點擊當前租戶
  await user.click(screen.getByText('Gym A'));
  // 應該顯示所有租戶
  expect(screen.getByText('Gym B')).toBeInTheDocument();
  expect(screen.getByText('Gym C')).toBeInTheDocument();
});
現在讓測試通過:
// TenantSwitcher.tsx
import { useState } from 'react';
import { Menu, Button } from '@mantine/core';
export function TenantSwitcher({ current, tenants }: TenantSwitcherProps) {
  const [opened, setOpened] = useState(false);
  return (
    <Menu opened={opened} onChange={setOpened}>
      <Menu.Target>
        <Button onClick={() => setOpened(!opened)}>
          {current.name}
        </Button>
      </Menu.Target>
      <Menu.Dropdown>
        {tenants.map(tenant => (
          <Menu.Item key={tenant.id}>
            {tenant.name}
          </Menu.Item>
        ))}
      </Menu.Dropdown>
    </Menu>
  );
}
// TenantSwitcher.test.tsx
it('should call onSwitch when tenant is selected', async () => {
  const user = userEvent.setup();
  const onSwitch = vi.fn();
  const currentTenant = {
    id: '1',
    name: 'Gym A',
    slug: 'gym-a',
  };
  const tenants = [
    currentTenant,
    { id: '2', name: 'Gym B', slug: 'gym-b' },
  ];
  render(
    <TenantSwitcher
      current={currentTenant}
      tenants={tenants}
      onSwitch={onSwitch}
    />
  );
  // 開啟選單
  await user.click(screen.getByText('Gym A'));
  // 選擇 Gym B
  await user.click(screen.getByText('Gym B'));
  // 應該呼叫 onSwitch
  expect(onSwitch).toHaveBeenCalledWith({
    id: '2',
    name: 'Gym B',
    slug: 'gym-b',
  });
});
實作:
// TenantSwitcher.tsx
interface TenantSwitcherProps {
  current: Tenant;
  tenants: Tenant[];
  onSwitch?: (tenant: Tenant) => void;
}
export function TenantSwitcher({ current, tenants, onSwitch }: TenantSwitcherProps) {
  const [opened, setOpened] = useState(false);
  const handleSelect = (tenant: Tenant) => {
    setOpened(false);
    onSwitch?.(tenant);
  };
  return (
    <Menu opened={opened} onChange={setOpened}>
      <Menu.Target>
        <Button onClick={() => setOpened(!opened)}>
          {current.name}
        </Button>
      </Menu.Target>
      <Menu.Dropdown>
        {tenants.map(tenant => (
          <Menu.Item
            key={tenant.id}
            onClick={() => handleSelect(tenant)}
          >
            {tenant.name}
          </Menu.Item>
        ))}
      </Menu.Dropdown>
    </Menu>
  );
}
// TenantSwitcher.test.tsx
it('should highlight current tenant in the list', async () => {
  const user = userEvent.setup();
  const currentTenant = {
    id: '1',
    name: 'Gym A',
    slug: 'gym-a',
  };
  const tenants = [
    currentTenant,
    { id: '2', name: 'Gym B', slug: 'gym-b' },
  ];
  render(<TenantSwitcher current={currentTenant} tenants={tenants} />);
  await user.click(screen.getByText('Gym A'));
  // 當前租戶應該有特殊標記
  const currentItem = screen.getByRole('menuitem', { name: /Gym A/i });
  expect(currentItem).toHaveAttribute('data-active', 'true');
});
it('should show tenant switch loading state', () => {
  const currentTenant = {
    id: '1',
    name: 'Gym A',
    slug: 'gym-a',
  };
  render(
    <TenantSwitcher
      current={currentTenant}
      tenants={[]}
      isLoading={true}
    />
  );
  expect(screen.getByRole('button')).toHaveAttribute('data-loading', 'true');
});
完整實作:
// TenantSwitcher.tsx
import { useState } from 'react';
import { Menu, Button, Loader } from '@mantine/core';
import { IconBuilding, IconCheck } from '@tabler/icons-react';
interface Tenant {
  id: string;
  name: string;
  slug: string;
}
interface TenantSwitcherProps {
  current: Tenant;
  tenants: Tenant[];
  onSwitch?: (tenant: Tenant) => void;
  isLoading?: boolean;
}
export function TenantSwitcher({
  current,
  tenants,
  onSwitch,
  isLoading = false,
}: TenantSwitcherProps) {
  const [opened, setOpened] = useState(false);
  const handleSelect = (tenant: Tenant) => {
    if (tenant.id === current.id) return;
    setOpened(false);
    onSwitch?.(tenant);
  };
  return (
    <Menu opened={opened} onChange={setOpened} width={260}>
      <Menu.Target>
        <Button
          leftSection={<IconBuilding size={16} />}
          rightSection={isLoading ? <Loader size="xs" /> : null}
          variant="subtle"
          data-loading={isLoading}
        >
          {current.name}
        </Button>
      </Menu.Target>
      <Menu.Dropdown>
        <Menu.Label>切換租戶</Menu.Label>
        {tenants.map(tenant => {
          const isCurrent = tenant.id === current.id;
          return (
            <Menu.Item
              key={tenant.id}
              onClick={() => handleSelect(tenant)}
              data-active={isCurrent}
              rightSection={isCurrent ? <IconCheck size={16} /> : null}
              style={{
                backgroundColor: isCurrent
                  ? 'var(--mantine-color-blue-light)'
                  : undefined,
              }}
            >
              {tenant.name}
            </Menu.Item>
          );
        })}
      </Menu.Dropdown>
    </Menu>
  );
}
// src/components/CourseScheduler/CourseScheduler.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CourseScheduler } from './CourseScheduler';
describe('CourseScheduler - Async Operations', () => {
  let queryClient: QueryClient;
  beforeEach(() => {
    queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
        mutations: { retry: false },
      },
    });
  });
  it('should show loading state while fetching courses', async () => {
    // Mock API 延遲回應
    const mockFetch = vi.fn(() =>
      new Promise(resolve =>
        setTimeout(() => resolve({ data: [] }), 100)
      )
    );
    render(
      <QueryClientProvider client={queryClient}>
        <CourseScheduler fetchCourses={mockFetch} />
      </QueryClientProvider>
    );
    // 應該立即顯示 loading
    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
    // 等待資料載入完成
    await waitFor(() => {
      expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
    });
    expect(mockFetch).toHaveBeenCalledOnce();
  });
  it('should handle API errors gracefully', async () => {
    const mockFetch = vi.fn(() =>
      Promise.reject(new Error('Network error'))
    );
    render(
      <QueryClientProvider client={queryClient}>
        <CourseScheduler fetchCourses={mockFetch} />
      </QueryClientProvider>
    );
    // 等待錯誤訊息出現
    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
    expect(screen.getByText(/network error/i)).toBeInTheDocument();
  });
  it('should refetch data when retry button is clicked', async () => {
    const user = userEvent.setup();
    const mockFetch = vi
      .fn()
      .mockRejectedValueOnce(new Error('Failed'))
      .mockResolvedValueOnce({ data: [{ id: 1, name: 'Course A' }] });
    render(
      <QueryClientProvider client={queryClient}>
        <CourseScheduler fetchCourses={mockFetch} />
      </QueryClientProvider>
    );
    // 等待錯誤出現
    await waitFor(() => {
      expect(screen.getByText(/failed/i)).toBeInTheDocument();
    });
    // 點擊重試按鈕
    const retryButton = screen.getByRole('button', { name: /retry/i });
    await user.click(retryButton);
    // 等待成功載入
    await waitFor(() => {
      expect(screen.getByText('Course A')).toBeInTheDocument();
    });
    expect(mockFetch).toHaveBeenCalledTimes(2);
  });
});
// src/components/Members/MemberForm.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { MemberForm } from './MemberForm';
describe('MemberForm - Complex Interactions', () => {
  it('should validate form before submission', async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn();
    render(<MemberForm onSubmit={onSubmit} />);
    // 不填寫任何欄位直接送出
    const submitButton = screen.getByRole('button', { name: /submit/i });
    await user.click(submitButton);
    // 應該顯示驗證錯誤
    expect(await screen.findByText(/name is required/i)).toBeInTheDocument();
    expect(screen.getByText(/email is required/i)).toBeInTheDocument();
    // 不應該呼叫 onSubmit
    expect(onSubmit).not.toHaveBeenCalled();
  });
  it('should validate email format in real-time', async () => {
    const user = userEvent.setup();
    render(<MemberForm />);
    const emailInput = screen.getByLabelText(/email/i);
    // 輸入無效的 email
    await user.type(emailInput, 'invalid-email');
    await user.tab(); // 觸發 blur 事件
    // 應該顯示格式錯誤
    expect(
      await screen.findByText(/invalid email format/i)
    ).toBeInTheDocument();
    // 修正為有效的 email
    await user.clear(emailInput);
    await user.type(emailInput, 'user@example.com');
    await user.tab();
    // 錯誤訊息應該消失
    await waitFor(() => {
      expect(
        screen.queryByText(/invalid email format/i)
      ).not.toBeInTheDocument();
    });
  });
  it('should handle multi-step form navigation', async () => {
    const user = userEvent.setup();
    render(<MemberForm mode="create" steps={['basic', 'membership', 'payment']} />);
    // Step 1: 基本資訊
    expect(screen.getByText(/step 1/i)).toBeInTheDocument();
    await user.type(screen.getByLabelText(/name/i), 'John Doe');
    await user.type(screen.getByLabelText(/email/i), 'john@example.com');
    // 下一步
    await user.click(screen.getByRole('button', { name: /next/i }));
    // Step 2: 會員方案
    await waitFor(() => {
      expect(screen.getByText(/step 2/i)).toBeInTheDocument();
    });
    await user.click(screen.getByLabelText(/premium plan/i));
    // 返回上一步
    await user.click(screen.getByRole('button', { name: /back/i }));
    // 應該回到 Step 1,且資料保留
    expect(screen.getByLabelText(/name/i)).toHaveValue('John Doe');
    // 再次前進
    await user.click(screen.getByRole('button', { name: /next/i }));
    await user.click(screen.getByRole('button', { name: /next/i }));
    // Step 3: 付款資訊
    expect(screen.getByText(/step 3/i)).toBeInTheDocument();
  });
});
// src/stores/auth.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAuthStore } from './auth';
describe('AuthStore', () => {
  beforeEach(() => {
    // 重置 store 狀態
    useAuthStore.setState({
      user: null,
      token: null,
      isAuthenticated: false,
    });
  });
  it('should initialize with unauthenticated state', () => {
    const { result } = renderHook(() => useAuthStore());
    expect(result.current.user).toBeNull();
    expect(result.current.token).toBeNull();
    expect(result.current.isAuthenticated).toBe(false);
  });
  it('should set user and token on login', () => {
    const { result } = renderHook(() => useAuthStore());
    const user = {
      id: '1',
      name: 'John Doe',
      email: 'john@example.com',
    };
    const token = 'mock-jwt-token';
    act(() => {
      result.current.login(user, token);
    });
    expect(result.current.user).toEqual(user);
    expect(result.current.token).toBe(token);
    expect(result.current.isAuthenticated).toBe(true);
  });
  it('should clear state on logout', () => {
    const { result } = renderHook(() => useAuthStore());
    // 先登入
    act(() => {
      result.current.login(
        { id: '1', name: 'John', email: 'john@test.com' },
        'token'
      );
    });
    expect(result.current.isAuthenticated).toBe(true);
    // 登出
    act(() => {
      result.current.logout();
    });
    expect(result.current.user).toBeNull();
    expect(result.current.token).toBeNull();
    expect(result.current.isAuthenticated).toBe(false);
  });
  it('should persist token to localStorage on login', () => {
    const { result } = renderHook(() => useAuthStore());
    const token = 'persistent-token';
    act(() => {
      result.current.login(
        { id: '1', name: 'Jane', email: 'jane@test.com' },
        token
      );
    });
    expect(localStorage.getItem('auth-token')).toBe(token);
  });
  it('should remove token from localStorage on logout', () => {
    const { result } = renderHook(() => useAuthStore());
    // 先設置 token
    localStorage.setItem('auth-token', 'some-token');
    act(() => {
      result.current.logout();
    });
    expect(localStorage.getItem('auth-token')).toBeNull();
  });
});
// src/hooks/useAuth.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useAuth } from './useAuth';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
describe('useAuth Hook', () => {
  let queryClient: QueryClient;
  beforeEach(() => {
    queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
      },
    });
  });
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
  it('should return current user when authenticated', async () => {
    // Mock API
    global.fetch = vi.fn(() =>
      Promise.resolve({
        ok: true,
        json: () =>
          Promise.resolve({
            user: { id: '1', name: 'Test User' },
          }),
      })
    ) as any;
    const { result } = renderHook(() => useAuth(), { wrapper });
    await waitFor(() => {
      expect(result.current.user).toEqual({
        id: '1',
        name: 'Test User',
      });
      expect(result.current.isLoading).toBe(false);
    });
  });
  it('should handle login mutation', async () => {
    global.fetch = vi.fn(() =>
      Promise.resolve({
        ok: true,
        json: () =>
          Promise.resolve({
            token: 'new-token',
            user: { id: '2', name: 'Logged In User' },
          }),
      })
    ) as any;
    const { result } = renderHook(() => useAuth(), { wrapper });
    // 執行登入
    act(() => {
      result.current.login({
        email: 'user@test.com',
        password: 'password',
      });
    });
    await waitFor(() => {
      expect(result.current.isAuthenticated).toBe(true);
      expect(result.current.user?.name).toBe('Logged In User');
    });
  });
});
// ❌ 避免過度使用 data-testid
<div data-testid="user-profile">
  <h1 data-testid="user-name">{user.name}</h1>
  <p data-testid="user-email">{user.email}</p>
</div>
// ✅ 優先使用語義化查詢
<div role="region" aria-label="User Profile">
  <h1>{user.name}</h1>
  <p>{user.email}</p>
</div>
// 測試:
expect(screen.getByRole('region', { name: /user profile/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(user.name);
// ❌ 過度 mock
vi.mock('@mantine/core', () => ({
  Button: ({ children, onClick }: any) => (
    <button onClick={onClick}>{children}</button>
  ),
  Menu: ({ children }: any) => <div>{children}</div>,
  // ... mock 所有元件
}));
// ✅ 只 mock 必要的外部依賴
vi.mock('./api/client', () => ({
  fetchCourses: vi.fn(),
  createCourse: vi.fn(),
}));
// 保持 UI 元件使用真實實作
// ❌ 不清楚的測試描述
it('works', () => {
  // ...
});
it('test case 1', () => {
  // ...
});
// ✅ 清楚描述行為
it('should display error message when email format is invalid', () => {
  // ...
});
it('should call onSubmit with form data when all validations pass', () => {
  // ...
});
# 執行測試並生成覆蓋率報告
pnpm test -- --coverage
# 覆蓋率報告範例:
# ┌────────────────────┬───────┬────────┬─────────┬─────────┐
# │ File               │ % Stmts│ % Branch│ % Funcs │ % Lines │
# ├────────────────────┼───────┼────────┼─────────┼─────────┤
# │ TenantSwitcher.tsx │  95.45│  88.89 │  100.00 │  95.23  │
# │ MemberForm.tsx     │  87.50│  81.25 │   90.00 │  86.96  │
# │ useAuth.ts         │  92.31│  85.71 │   88.89 │  91.67  │
# └────────────────────┴───────┴────────┴─────────┴─────────┘
我們今天實作了前端測試的進階技巧: